"use client"; /** * /w/:workspace/templates — the workspace's page templates (PR-122/123). * * List → GET /workspaces/:wid/templates ({ data: Template[] } — templates * from this workspace AND every workspace the user belongs to; * builtin rows are read-only) * Create → POST /pages/:pageId/templates (snapshot a page's subtree; from * the "save as template" affordance — the Templates page applies, * the editor's insert dialog creates the source) * Apply → POST /templates/:id/apply (deep-copies the snapshot into * THIS workspace; the response is the new page — we link to it) * * Scoping note: the list endpoint intentionally unions every workspace the * caller belongs to (the editor's insert dialog reuses templates across * workspaces) and the rows carry no workspaceId, so the page shows that union * (the same set the insert dialog offers). Recorded, not a filter bug. */ import { useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Copy, Eye, FilePlus2, FileText, Loader2, Lock, Search } from "lucide-react"; import { templateApi } from "@/lib/api"; import { toast } from "@/stores/ui-store"; import { useWorkspace } from "@/hooks/use-workspace"; import { TemplatePreviewDialog } from "@/components/dialogs/template-preview-dialog"; import { TemplateVariableDialog } from "@/components/dialogs/template-variable-dialog"; import { TemplateInfo } from "@/lib/types"; export default function TemplatesPage() { const params = useParams<{ workspace: string }>(); const slugOrId = params?.workspace ?? ""; const workspaceSlug = params?.workspace ?? "default"; const router = useRouter(); const { data: workspace } = useWorkspace(slugOrId); const qc = useQueryClient(); // T-141: the directory is filtered SERVER-side (search = case-insensitive // contains on name+description, category = exact). `all` (unfiltered) feeds // the category dropdown — the filtered list is narrowed, so the options // cannot be derived from it (you could not switch back to another category). const [search, setSearch] = useState(""); const [category, setCategory] = useState(""); const filtersActive = search.trim() !== "" || category !== ""; const all = useQuery({ queryKey: ["workspaces", workspace?.id ?? slugOrId, "templates", "all"], queryFn: () => templateApi.list(workspace?.id ?? slugOrId), enabled: !!workspace?.id, retry: false }); const filtered = useQuery({ queryKey: ["workspaces", workspace?.id ?? slugOrId, "templates", { search: search.trim(), category }], queryFn: () => templateApi.list(workspace?.id ?? slugOrId, { ...(search.trim() ? { search: search.trim() } : {}), ...(category ? { category } : {}) }), enabled: !!workspace?.id && filtersActive, retry: false }); const source = filtersActive ? filtered : all; const items = source.data ?? []; const isLoading = source.isLoading; const isError = source.isError; const categories = useMemo( () => [...new Set((all.data ?? []).map((t) => t.category).filter((c): c is string => Boolean(c)))].sort(), [all.data] ); const [applyingId, setApplyingId] = useState(null); // T-142: the variable template currently open in the RHF dialog (null = closed). const [varsTemplate, setVarsTemplate] = useState(null); // T-141: the template open in the read-only preview dialog (null = closed). const [previewTemplate, setPreviewTemplate] = useState(null); const apply = useMutation({ mutationFn: (t: TemplateInfo) => templateApi.apply(t.id, { workspaceId: workspace?.id ?? "", parentId: null, title: t.name }), onMutate: (t) => setApplyingId(t.id), onSuccess: (page, t) => { setApplyingId(null); toast("success", `Template “${t.name}” applied`, "Opening the new page…"); qc.invalidateQueries({ queryKey: ["workspaces", workspace?.id, "templates"] }); qc.invalidateQueries({ queryKey: ["workspaces", workspace?.id, "page-tree"] }); router.push(`/w/${workspaceSlug}/page/${page.id}`); }, onError: (err) => { setApplyingId(null); toast("error", "Could not apply template", err instanceof Error ? err.message : undefined); } }); // T-142: a template with declared variables goes through the form dialog // (apply WITH values); everything else keeps the one-click apply. const hasVariables = (t: TemplateInfo): boolean => Array.isArray(t.variables) && t.variables.length > 0; const isOwnerOrAdmin = ["owner", "admin"].includes((workspace?.role ?? "").toLowerCase()); return ( <>

Templates

Reusable page structures. “Use” duplicates a template into this workspace as a new page. {isOwnerOrAdmin ? "" : " (Only owners and admins can apply templates in this workspace.)"}

{/* T-141: directory controls — search (name + description) + category filter. */}
setSearch(e.target.value)} placeholder="Search templates…" aria-label="Search templates" data-testid="template-search" className="w-full rounded-md border border-input bg-background py-1.5 pl-8 pr-2 text-sm placeholder:text-muted-foreground" />
{filtersActive && ( )}
{isLoading && (

Loading templates…

)} {isError && (

Couldn't load templates.

)} {!isLoading && !isError && items.length === 0 && (

{filtersActive ? "No templates match the current search or category." : "No templates in this workspace yet. Save one by snapshotting a page, or insert a template from the editor (slash menu → “Insert template”)."}

)}
    {items.map((t) => (
  • {t.icon || (t.type === "database" ? : )}

    {t.name || "Untitled"} {t.isBuiltin && ( Builtin )} {t.category && ( {t.category} )}

    {t.description ? (

    {t.description}

    ) : (

    {t.type === "database" ? "Database template" : "Page template"}

    )}
    {/* T-141: read-only preview — VIEW access, so available on every row (including builtins) to every member of the union. */} {!t.isBuiltin && isOwnerOrAdmin && ( )}
  • ))}
{previewTemplate ? ( setPreviewTemplate(null)} /> ) : null} {varsTemplate && workspace?.id ? ( setVarsTemplate(null)} /> ) : null} ); }